/*
 * Copyright (c) 2011-2017 Contributors to the Eclipse Foundation
 *
 * This program and the accompanying materials are made available under the
 * terms of the Eclipse Public License 2.0 which is available at
 * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0
 * which is available at https://www.apache.org/licenses/LICENSE-2.0.
 *
 * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0
 */
package io.vertx.core.json;

import io.vertx.core.buffer.Buffer;
import io.vertx.core.shareddata.Shareable;
import io.vertx.core.shareddata.impl.ClusterSerializable;
import io.vertx.core.spi.json.JsonCodec;

import java.nio.charset.StandardCharsets;
import java.sql.Date;
import java.sql.Time;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.Base64;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;

import static java.time.format.DateTimeFormatter.ISO_INSTANT;

/**
 * A representation of a <a href="http://json.org/">JSON</a> object in Java.
 *
 * <p>Unlike some other languages Java does not have a native understanding of JSON. To enable JSON
 * to be used easily in Vert.x code we use this class to encapsulate the notion of a JSON object.
 *
 * <p>The implementation adheres to the <a href="http://rfc-editor.org/rfc/rfc7493.txt">RFC-7493</a>
 * to support Temporal data types as well as binary data.
 *
 * <p>Please see the documentation for more information.
 *
 * @author <a href="http://tfox.org">Tim Fox</a>
 *     改动内容：在checkAndCopy法中，对Time、Date、Timestamp、byte[]类型直接返回，不强转为string
 *     改动原因：在vertx获取异步查询数据的时候将Time、Date、Timestamp、byte[]转换为string，导致类型转换问题
 */
public class JsonObject
        implements Iterable<Map.Entry<String, Object>>, ClusterSerializable, Shareable {

    private Map<String, Object> map;

    /**
     * Create an instance from a string of JSON
     *
     * @param json the string of JSON
     */
    public JsonObject(String json) {
        if (json == null) {
            throw new NullPointerException();
        }
        fromJson(json);
        if (map == null) {
            throw new DecodeException("Invalid JSON object: " + json);
        }
    }

    /** Create a new, empty instance */
    public JsonObject() {
        map = new LinkedHashMap<>();
    }

    /**
     * Create an instance from a Map. The Map is not copied.
     *
     * @param map the map to create the instance from.
     */
    public JsonObject(Map<String, Object> map) {
        if (map == null) {
            throw new NullPointerException();
        }
        this.map = map;
    }

    /**
     * Create an instance from a buffer.
     *
     * @param buf the buffer to create the instance from.
     */
    public JsonObject(Buffer buf) {
        if (buf == null) {
            throw new NullPointerException();
        }
        fromBuffer(buf);
        if (map == null) {
            throw new DecodeException("Invalid JSON object: " + buf);
        }
    }

    /**
     * Create a JsonObject from the fields of a Java object. Faster than calling `new
     * JsonObject(Json.encode(obj))`. <p/ Returns {@ode null} when {@code obj} is {@code null}.
     *
     * @param obj The object to convert to a JsonObject.
     * @throws IllegalArgumentException if conversion fails due to an incompatible type.
     */
    @SuppressWarnings("unchecked")
    public static JsonObject mapFrom(Object obj) {
        if (obj == null) {
            return null;
        } else {
            return new JsonObject(
                    (Map<String, Object>) JsonCodec.INSTANCE.fromValue(obj, Map.class));
        }
    }

    private static boolean objectEquals(Map<?, ?> m1, Object o2) {
        Map<?, ?> m2;
        if (o2 instanceof JsonObject) {
            m2 = ((JsonObject) o2).map;
        } else if (o2 instanceof Map<?, ?>) {
            m2 = (Map<?, ?>) o2;
        } else {
            return false;
        }
        if (!m1.keySet().equals(m2.keySet())) {
            return false;
        }
        for (Map.Entry<?, ?> entry : m1.entrySet()) {
            Object val1 = entry.getValue();
            Object val2 = m2.get(entry.getKey());
            if (val1 == null ? val2 != null : !equals(val1, val2)) {
                return false;
            }
        }
        return true;
    }

    static boolean equals(Object o1, Object o2) {
        if (o1 == o2) {
            return true;
        }
        if (o1 instanceof JsonObject) {
            return objectEquals(((JsonObject) o1).map, o2);
        }
        if (o1 instanceof Map<?, ?>) {
            return objectEquals((Map<?, ?>) o1, o2);
        }
        if (o1 instanceof JsonArray) {
            return JsonArray.arrayEquals(((JsonArray) o1).getList(), o2);
        }
        if (o1 instanceof List<?>) {
            return JsonArray.arrayEquals((List<?>) o1, o2);
        }
        if (o1 instanceof Number && o2 instanceof Number && o1.getClass() != o2.getClass()) {
            Number n1 = (Number) o1;
            Number n2 = (Number) o2;
            if (o1 instanceof Float
                    || o1 instanceof Double
                    || o2 instanceof Float
                    || o2 instanceof Double) {
                return n1.doubleValue() == n2.doubleValue();
            } else {
                return n1.longValue() == n2.longValue();
            }
        }
        return o1.equals(o2);
    }

    @SuppressWarnings("unchecked")
    static Object checkAndCopy(Object val, boolean copy) {
        if (val == null) {
            // OK
            // DTSTACK fix remove BigDecimal check
        } else if (val instanceof Number) {
            // OK
        } else if (val instanceof Boolean) {
            // OK
        } else if (val instanceof String) {
            // OK
        } else if (val instanceof Character) {
            // OK
        } else if (val instanceof CharSequence) {
            val = val.toString();
        } else if (val instanceof JsonObject) {
            if (copy) {
                val = ((JsonObject) val).copy();
            }
        } else if (val instanceof JsonArray) {
            if (copy) {
                val = ((JsonArray) val).copy();
            }
        } else if (val instanceof Map) {
            if (copy) {
                val = (new JsonObject((Map) val)).copy();
            } else {
                val = new JsonObject((Map) val);
            }
        } else if (val instanceof List) {
            if (copy) {
                val = (new JsonArray((List) val)).copy();
            } else {
                val = new JsonArray((List) val);
            }
        } else if (val instanceof byte[]) {
            // DTSTACK fix remove timestamp check
            // ok
            // val = Base64.getEncoder().encodeToString((byte[])val);
        } else if (val instanceof Instant) {
            val = ISO_INSTANT.format((Instant) val);
        } else if (val instanceof Timestamp) {
            // DTSTACK fix remove timestamp check
            // ok
        } else if (val instanceof Date) {
            // DTSTACK fix remove timestamp check
            // ok
        } else if (val instanceof Time) {
            // DTSTACK fix remove timestamp check
            // ok
        } else {
            throw new IllegalStateException("Illegal type in JsonObject: " + val.getClass());
        }
        return val;
    }

    static <T> Stream<T> asStream(Iterator<T> sourceIterator) {
        Iterable<T> iterable = () -> sourceIterator;
        return StreamSupport.stream(iterable.spliterator(), false);
    }

    /**
     * Instantiate a Java object from a JsonObject. Faster than calling
     * `Json.decodeValue(Json.encode(jsonObject), type)`.
     *
     * @param type The type to instantiate from the JsonObject.
     * @throws IllegalArgumentException if the type cannot be instantiated.
     */
    public <T> T mapTo(Class<T> type) {
        return JsonCodec.INSTANCE.fromValue(map, type);
    }

    /**
     * Get the string value with the specified key
     *
     * @param key the key to return the value for
     * @return the value or null if no value for that key
     * @throws ClassCastException if the value is not a String
     */
    public String getString(String key) {
        Objects.requireNonNull(key);
        CharSequence cs = (CharSequence) map.get(key);
        return cs == null ? null : cs.toString();
    }

    /**
     * Get the Number value with the specified key
     *
     * @param key the key to return the value for
     * @return the value or null if no value for that key
     * @throws ClassCastException if the value is not a Number
     */
    public Number getNumber(String key) {
        Objects.requireNonNull(key);
        return (Number) map.get(key);
    }

    /**
     * Get the Integer value with the specified key
     *
     * @param key the key to return the value for
     * @return the value or null if no value for that key
     * @throws ClassCastException if the value is not an Integer
     */
    public Integer getInteger(String key) {
        Objects.requireNonNull(key);
        Number number = (Number) map.get(key);
        if (number == null) {
            return null;
        } else if (number instanceof Integer) {
            return (Integer) number; // Avoids unnecessary unbox/box
        } else {
            return number.intValue();
        }
    }

    /**
     * Get the Long value with the specified key
     *
     * @param key the key to return the value for
     * @return the value or null if no value for that key
     * @throws ClassCastException if the value is not a Long
     */
    public Long getLong(String key) {
        Objects.requireNonNull(key);
        Number number = (Number) map.get(key);
        if (number == null) {
            return null;
        } else if (number instanceof Long) {
            return (Long) number; // Avoids unnecessary unbox/box
        } else {
            return number.longValue();
        }
    }

    /**
     * Get the Double value with the specified key
     *
     * @param key the key to return the value for
     * @return the value or null if no value for that key
     * @throws ClassCastException if the value is not a Double
     */
    public Double getDouble(String key) {
        Objects.requireNonNull(key);
        Number number = (Number) map.get(key);
        if (number == null) {
            return null;
        } else if (number instanceof Double) {
            return (Double) number; // Avoids unnecessary unbox/box
        } else {
            return number.doubleValue();
        }
    }

    /**
     * Get the Float value with the specified key
     *
     * @param key the key to return the value for
     * @return the value or null if no value for that key
     * @throws ClassCastException if the value is not a Float
     */
    public Float getFloat(String key) {
        Objects.requireNonNull(key);
        Number number = (Number) map.get(key);
        if (number == null) {
            return null;
        } else if (number instanceof Float) {
            return (Float) number; // Avoids unnecessary unbox/box
        } else {
            return number.floatValue();
        }
    }

    /**
     * Get the Boolean value with the specified key
     *
     * @param key the key to return the value for
     * @return the value or null if no value for that key
     * @throws ClassCastException if the value is not a Boolean
     */
    public Boolean getBoolean(String key) {
        Objects.requireNonNull(key);
        return (Boolean) map.get(key);
    }

    /**
     * Get the JsonObject value with the specified key
     *
     * @param key the key to return the value for
     * @return the value or null if no value for that key
     * @throws ClassCastException if the value is not a JsonObject
     */
    public JsonObject getJsonObject(String key) {
        Objects.requireNonNull(key);
        Object val = map.get(key);
        if (val instanceof Map) {
            val = new JsonObject((Map) val);
        }
        return (JsonObject) val;
    }

    /**
     * Get the JsonArray value with the specified key
     *
     * @param key the key to return the value for
     * @return the value or null if no value for that key
     * @throws ClassCastException if the value is not a JsonArray
     */
    public JsonArray getJsonArray(String key) {
        Objects.requireNonNull(key);
        Object val = map.get(key);
        if (val instanceof List) {
            val = new JsonArray((List) val);
        }
        return (JsonArray) val;
    }

    /**
     * Get the binary value with the specified key.
     *
     * <p>JSON itself has no notion of a binary, this extension complies to the RFC-7493, so this
     * method assumes there is a String value with the key and it contains a Base64 encoded binary,
     * which it decodes if found and returns.
     *
     * <p>This method should be used in conjunction with {@link #put(String, byte[])}
     *
     * @param key the key to return the value for
     * @return the value or null if no value for that key
     * @throws ClassCastException if the value is not a String
     * @throws IllegalArgumentException if the String value is not a legal Base64 encoded value
     */
    public byte[] getBinary(String key) {
        Objects.requireNonNull(key);
        String encoded = (String) map.get(key);
        return encoded == null ? null : Base64.getDecoder().decode(encoded);
    }

    /**
     * Get the instant value with the specified key.
     *
     * <p>JSON itself has no notion of a temporal types, this extension complies to the RFC-7493, so
     * this method assumes there is a String value with the key and it contains an ISO 8601 encoded
     * date and time format such as "2017-04-03T10:25:41Z", which it decodes if found and returns.
     *
     * <p>This method should be used in conjunction with {@link #put(String, Instant)}
     *
     * @param key the key to return the value for
     * @return the value or null if no value for that key
     * @throws ClassCastException if the value is not a String
     * @throws java.time.format.DateTimeParseException if the String value is not a legal ISO 8601
     *     encoded value
     */
    public Instant getInstant(String key) {
        Objects.requireNonNull(key);
        String encoded = (String) map.get(key);
        return encoded == null ? null : Instant.from(ISO_INSTANT.parse(encoded));
    }

    /**
     * Get the value with the specified key, as an Object
     *
     * @param key the key to lookup
     * @return the value
     */
    public Object getValue(String key) {
        Objects.requireNonNull(key);
        Object val = map.get(key);
        if (val instanceof Map) {
            val = new JsonObject((Map) val);
        } else if (val instanceof List) {
            val = new JsonArray((List) val);
        }
        return val;
    }

    /**
     * Like {@link #getString(String)} but specifying a default value to return if there is no
     * entry.
     *
     * @param key the key to lookup
     * @param def the default value to use if the entry is not present
     * @return the value or {@code def} if no entry present
     */
    public String getString(String key, String def) {
        Objects.requireNonNull(key);
        CharSequence cs = (CharSequence) map.get(key);
        return cs != null || map.containsKey(key) ? cs == null ? null : cs.toString() : def;
    }

    /**
     * Like {@link #getNumber(String)} but specifying a default value to return if there is no
     * entry.
     *
     * @param key the key to lookup
     * @param def the default value to use if the entry is not present
     * @return the value or {@code def} if no entry present
     */
    public Number getNumber(String key, Number def) {
        Objects.requireNonNull(key);
        if (map.containsKey(key)) {
            return getNumber(key);
        } else {
            return def;
        }
    }

    /**
     * Like {@link #getInteger(String)} but specifying a default value to return if there is no
     * entry.
     *
     * @param key the key to lookup
     * @param def the default value to use if the entry is not present
     * @return the value or {@code def} if no entry present
     */
    public Integer getInteger(String key, Integer def) {
        Objects.requireNonNull(key);
        Number val = (Number) map.get(key);
        if (val == null) {
            if (map.containsKey(key)) {
                return null;
            } else {
                return def;
            }
        } else if (val instanceof Integer) {
            return (Integer) val; // Avoids unnecessary unbox/box
        } else {
            return val.intValue();
        }
    }

    /**
     * Like {@link #getLong(String)} but specifying a default value to return if there is no entry.
     *
     * @param key the key to lookup
     * @param def the default value to use if the entry is not present
     * @return the value or {@code def} if no entry present
     */
    public Long getLong(String key, Long def) {
        Objects.requireNonNull(key);
        Number val = (Number) map.get(key);
        if (val == null) {
            if (map.containsKey(key)) {
                return null;
            } else {
                return def;
            }
        } else if (val instanceof Long) {
            return (Long) val; // Avoids unnecessary unbox/box
        } else {
            return val.longValue();
        }
    }

    /**
     * Like {@link #getDouble(String)} but specifying a default value to return if there is no
     * entry.
     *
     * @param key the key to lookup
     * @param def the default value to use if the entry is not present
     * @return the value or {@code def} if no entry present
     */
    public Double getDouble(String key, Double def) {
        Objects.requireNonNull(key);
        Number val = (Number) map.get(key);
        if (val == null) {
            if (map.containsKey(key)) {
                return null;
            } else {
                return def;
            }
        } else if (val instanceof Double) {
            return (Double) val; // Avoids unnecessary unbox/box
        } else {
            return val.doubleValue();
        }
    }

    /**
     * Like {@link #getFloat(String)} but specifying a default value to return if there is no entry.
     *
     * @param key the key to lookup
     * @param def the default value to use if the entry is not present
     * @return the value or {@code def} if no entry present
     */
    public Float getFloat(String key, Float def) {
        Objects.requireNonNull(key);
        Number val = (Number) map.get(key);
        if (val == null) {
            if (map.containsKey(key)) {
                return null;
            } else {
                return def;
            }
        } else if (val instanceof Float) {
            return (Float) val; // Avoids unnecessary unbox/box
        } else {
            return val.floatValue();
        }
    }

    /**
     * Like {@link #getBoolean(String)} but specifying a default value to return if there is no
     * entry.
     *
     * @param key the key to lookup
     * @param def the default value to use if the entry is not present
     * @return the value or {@code def} if no entry present
     */
    public Boolean getBoolean(String key, Boolean def) {
        Objects.requireNonNull(key);
        Object val = map.get(key);
        return val != null || map.containsKey(key) ? (Boolean) val : def;
    }

    /**
     * Like {@link #getJsonObject(String)} but specifying a default value to return if there is no
     * entry.
     *
     * @param key the key to lookup
     * @param def the default value to use if the entry is not present
     * @return the value or {@code def} if no entry present
     */
    public JsonObject getJsonObject(String key, JsonObject def) {
        JsonObject val = getJsonObject(key);
        return val != null || map.containsKey(key) ? val : def;
    }

    /**
     * Like {@link #getJsonArray(String)} but specifying a default value to return if there is no
     * entry.
     *
     * @param key the key to lookup
     * @param def the default value to use if the entry is not present
     * @return the value or {@code def} if no entry present
     */
    public JsonArray getJsonArray(String key, JsonArray def) {
        JsonArray val = getJsonArray(key);
        return val != null || map.containsKey(key) ? val : def;
    }

    /**
     * Like {@link #getBinary(String)} but specifying a default value to return if there is no
     * entry.
     *
     * @param key the key to lookup
     * @param def the default value to use if the entry is not present
     * @return the value or {@code def} if no entry present
     */
    public byte[] getBinary(String key, byte[] def) {
        Objects.requireNonNull(key);
        Object val = map.get(key);
        return val != null || map.containsKey(key)
                ? (val == null ? null : Base64.getDecoder().decode((String) val))
                : def;
    }

    /**
     * Like {@link #getInstant(String)} but specifying a default value to return if there is no
     * entry.
     *
     * @param key the key to lookup
     * @param def the default value to use if the entry is not present
     * @return the value or {@code def} if no entry present
     */
    public Instant getInstant(String key, Instant def) {
        Objects.requireNonNull(key);
        Object val = map.get(key);
        return val != null || map.containsKey(key)
                ? (val == null ? null : Instant.from(ISO_INSTANT.parse((String) val)))
                : def;
    }

    /**
     * Like {@link #getValue(String)} but specifying a default value to return if there is no entry.
     *
     * @param key the key to lookup
     * @param def the default value to use if the entry is not present
     * @return the value or {@code def} if no entry present
     */
    public Object getValue(String key, Object def) {
        Objects.requireNonNull(key);
        Object val = getValue(key);
        return val != null || map.containsKey(key) ? val : def;
    }

    /**
     * Does the JSON object contain the specified key?
     *
     * @param key the key
     * @return true if it contains the key, false if not.
     */
    public boolean containsKey(String key) {
        Objects.requireNonNull(key);
        return map.containsKey(key);
    }

    /**
     * Return the set of field names in the JSON objects
     *
     * @return the set of field names
     */
    public Set<String> fieldNames() {
        return map.keySet();
    }

    /**
     * Put an Enum into the JSON object with the specified key.
     *
     * <p>JSON has no concept of encoding Enums, so the Enum will be converted to a String using the
     * {@link Enum#name()} method and the value put as a String.
     *
     * @param key the key
     * @param value the value
     * @return a reference to this, so the API can be used fluently
     */
    public JsonObject put(String key, Enum value) {
        Objects.requireNonNull(key);
        map.put(key, value == null ? null : value.name());
        return this;
    }

    /**
     * Put an CharSequence into the JSON object with the specified key.
     *
     * @param key the key
     * @param value the value
     * @return a reference to this, so the API can be used fluently
     */
    public JsonObject put(String key, CharSequence value) {
        Objects.requireNonNull(key);
        map.put(key, value == null ? null : value.toString());
        return this;
    }

    /**
     * Put a String into the JSON object with the specified key.
     *
     * @param key the key
     * @param value the value
     * @return a reference to this, so the API can be used fluently
     */
    public JsonObject put(String key, String value) {
        Objects.requireNonNull(key);
        map.put(key, value);
        return this;
    }

    /**
     * Put an Integer into the JSON object with the specified key.
     *
     * @param key the key
     * @param value the value
     * @return a reference to this, so the API can be used fluently
     */
    public JsonObject put(String key, Integer value) {
        Objects.requireNonNull(key);
        map.put(key, value);
        return this;
    }

    /**
     * Put a Long into the JSON object with the specified key.
     *
     * @param key the key
     * @param value the value
     * @return a reference to this, so the API can be used fluently
     */
    public JsonObject put(String key, Long value) {
        Objects.requireNonNull(key);
        map.put(key, value);
        return this;
    }

    /**
     * Put a Double into the JSON object with the specified key.
     *
     * @param key the key
     * @param value the value
     * @return a reference to this, so the API can be used fluently
     */
    public JsonObject put(String key, Double value) {
        Objects.requireNonNull(key);
        map.put(key, value);
        return this;
    }

    /**
     * Put a Float into the JSON object with the specified key.
     *
     * @param key the key
     * @param value the value
     * @return a reference to this, so the API can be used fluently
     */
    public JsonObject put(String key, Float value) {
        Objects.requireNonNull(key);
        map.put(key, value);
        return this;
    }

    /**
     * Put a Boolean into the JSON object with the specified key.
     *
     * @param key the key
     * @param value the value
     * @return a reference to this, so the API can be used fluently
     */
    public JsonObject put(String key, Boolean value) {
        Objects.requireNonNull(key);
        map.put(key, value);
        return this;
    }

    /**
     * Put a null value into the JSON object with the specified key.
     *
     * @param key the key
     * @return a reference to this, so the API can be used fluently
     */
    public JsonObject putNull(String key) {
        Objects.requireNonNull(key);
        map.put(key, null);
        return this;
    }

    /**
     * Put another JSON object into the JSON object with the specified key.
     *
     * @param key the key
     * @param value the value
     * @return a reference to this, so the API can be used fluently
     */
    public JsonObject put(String key, JsonObject value) {
        Objects.requireNonNull(key);
        map.put(key, value);
        return this;
    }

    /**
     * Put a JSON array into the JSON object with the specified key.
     *
     * @param key the key
     * @param value the value
     * @return a reference to this, so the API can be used fluently
     */
    public JsonObject put(String key, JsonArray value) {
        Objects.requireNonNull(key);
        map.put(key, value);
        return this;
    }

    /**
     * Put a byte[] into the JSON object with the specified key.
     *
     * <p>JSON extension RFC7493, binary will first be Base64 encoded before being put as a String.
     *
     * @param key the key
     * @param value the value
     * @return a reference to this, so the API can be used fluently
     */
    public JsonObject put(String key, byte[] value) {
        Objects.requireNonNull(key);
        map.put(key, value == null ? null : Base64.getEncoder().encodeToString(value));
        return this;
    }

    /**
     * Put a Instant into the JSON object with the specified key.
     *
     * <p>JSON extension RFC7493, instant will first be encoded to ISO 8601 date and time String
     * such as "2017-04-03T10:25:41Z".
     *
     * @param key the key
     * @param value the value
     * @return a reference to this, so the API can be used fluently
     */
    public JsonObject put(String key, Instant value) {
        Objects.requireNonNull(key);
        map.put(key, value == null ? null : ISO_INSTANT.format(value));
        return this;
    }

    /**
     * Put an Object into the JSON object with the specified key.
     *
     * @param key the key
     * @param value the value
     * @return a reference to this, so the API can be used fluently
     */
    public JsonObject put(String key, Object value) {
        Objects.requireNonNull(key);
        value = checkAndCopy(value, false);
        map.put(key, value);
        return this;
    }

    /**
     * Remove an entry from this object.
     *
     * @param key the key
     * @return the value that was removed, or null if none
     */
    public Object remove(String key) {
        return map.remove(key);
    }

    /**
     * Merge in another JSON object.
     *
     * <p>This is the equivalent of putting all the entries of the other JSON object into this
     * object. This is not a deep merge, entries containing (sub) JSON objects will be replaced
     * entirely.
     *
     * @param other the other JSON object
     * @return a reference to this, so the API can be used fluently
     */
    public JsonObject mergeIn(JsonObject other) {
        return mergeIn(other, false);
    }

    /**
     * Merge in another JSON object. A deep merge (recursive) matches (sub) JSON objects in the
     * existing tree and replaces all matching entries. JsonArrays are treated like any other entry,
     * i.e. replaced entirely.
     *
     * @param other the other JSON object
     * @param deep if true, a deep merge is performed
     * @return a reference to this, so the API can be used fluently
     */
    public JsonObject mergeIn(JsonObject other, boolean deep) {
        return mergeIn(other, deep ? Integer.MAX_VALUE : 1);
    }

    /**
     * Merge in another JSON object. The merge is deep (recursive) to the specified level. If depth
     * is 0, no merge is performed, if depth is greater than the depth of one of the objects, a full
     * deep merge is performed.
     *
     * @param other the other JSON object
     * @param depth depth of merge
     * @return a reference to this, so the API can be used fluently
     */
    @SuppressWarnings("unchecked")
    public JsonObject mergeIn(JsonObject other, int depth) {
        if (depth < 1) {
            return this;
        }
        if (depth == 1) {
            map.putAll(other.map);
            return this;
        }
        for (Map.Entry<String, Object> e : other.map.entrySet()) {
            if (e.getValue() == null) {
                map.put(e.getKey(), null);
            } else {
                map.merge(
                        e.getKey(),
                        e.getValue(),
                        (oldVal, newVal) -> {
                            if (oldVal instanceof Map) {
                                oldVal = new JsonObject((Map) oldVal);
                            }
                            if (newVal instanceof Map) {
                                newVal = new JsonObject((Map) newVal);
                            }
                            if (oldVal instanceof JsonObject && newVal instanceof JsonObject) {
                                return ((JsonObject) oldVal)
                                        .mergeIn((JsonObject) newVal, depth - 1);
                            }
                            return newVal;
                        });
            }
        }
        return this;
    }

    /**
     * Encode this JSON object as a string.
     *
     * @return the string encoding.
     */
    public String encode() {
        return JsonCodec.INSTANCE.toString(map, false);
    }

    /**
     * Encode this JSON object a a string, with whitespace to make the object easier to read by a
     * human, or other sentient organism.
     *
     * @return the pretty string encoding.
     */
    public String encodePrettily() {
        return JsonCodec.INSTANCE.toString(map, true);
    }

    /**
     * Encode this JSON object as buffer.
     *
     * @return the buffer encoding.
     */
    public Buffer toBuffer() {
        return JsonCodec.INSTANCE.toBuffer(map, false);
    }

    /**
     * Copy the JSON object
     *
     * @return a copy of the object
     */
    @Override
    public JsonObject copy() {
        Map<String, Object> copiedMap;
        if (map instanceof LinkedHashMap) {
            copiedMap = new LinkedHashMap<>(map.size());
        } else {
            copiedMap = new HashMap<>(map.size());
        }
        for (Map.Entry<String, Object> entry : map.entrySet()) {
            Object val = entry.getValue();
            val = checkAndCopy(val, true);
            copiedMap.put(entry.getKey(), val);
        }
        return new JsonObject(copiedMap);
    }

    /**
     * Get the underlying {@code Map} as is.
     *
     * <p>This map may contain values that are not the types returned by the {@code JsonObject}.
     *
     * @return the underlying Map.
     */
    public Map<String, Object> getMap() {
        return map;
    }

    /**
     * Get a stream of the entries in the JSON object.
     *
     * @return a stream of the entries.
     */
    public Stream<Map.Entry<String, Object>> stream() {
        return asStream(iterator());
    }

    /**
     * Get an Iterator of the entries in the JSON object.
     *
     * @return an Iterator of the entries
     */
    @Override
    public Iterator<Map.Entry<String, Object>> iterator() {
        return new Iter(map.entrySet().iterator());
    }

    /**
     * Get the number of entries in the JSON object
     *
     * @return the number of entries
     */
    public int size() {
        return map.size();
    }

    /** Remove all the entries in this JSON object */
    public JsonObject clear() {
        map.clear();
        return this;
    }

    /**
     * Is this object entry?
     *
     * @return true if it has zero entries, false if not.
     */
    public boolean isEmpty() {
        return map.isEmpty();
    }

    @Override
    public String toString() {
        return encode();
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        return objectEquals(map, o);
    }

    @Override
    public int hashCode() {
        return map.hashCode();
    }

    @Override
    public void writeToBuffer(Buffer buffer) {
        String encoded = encode();
        byte[] bytes = encoded.getBytes(StandardCharsets.UTF_8);
        buffer.appendInt(bytes.length);
        buffer.appendBytes(bytes);
    }

    @Override
    public int readFromBuffer(int pos, Buffer buffer) {
        int length = buffer.getInt(pos);
        int start = pos + 4;
        String encoded = buffer.getString(start, start + length);
        fromJson(encoded);
        return pos + length + 4;
    }

    private void fromJson(String json) {
        map = JsonCodec.INSTANCE.fromString(json, Map.class);
    }

    private void fromBuffer(Buffer buf) {
        map = JsonCodec.INSTANCE.fromBuffer(buf, Map.class);
    }

    private static final class Entry implements Map.Entry<String, Object> {
        final String key;
        final Object value;

        public Entry(String key, Object value) {
            this.key = key;
            this.value = value;
        }

        @Override
        public String getKey() {
            return key;
        }

        @Override
        public Object getValue() {
            return value;
        }

        @Override
        public Object setValue(Object value) {
            throw new UnsupportedOperationException();
        }
    }

    private class Iter implements Iterator<Map.Entry<String, Object>> {

        final Iterator<Map.Entry<String, Object>> mapIter;

        Iter(Iterator<Map.Entry<String, Object>> mapIter) {
            this.mapIter = mapIter;
        }

        @Override
        public boolean hasNext() {
            return mapIter.hasNext();
        }

        @Override
        public Map.Entry<String, Object> next() {
            Map.Entry<String, Object> entry = mapIter.next();
            if (entry.getValue() instanceof Map) {
                return new Entry(entry.getKey(), new JsonObject((Map) entry.getValue()));
            } else if (entry.getValue() instanceof List) {
                return new Entry(entry.getKey(), new JsonArray((List) entry.getValue()));
            }
            return entry;
        }

        @Override
        public void remove() {
            mapIter.remove();
        }
    }
}
